spb/worthdoing Public
Autonomous investigation agent that discovers, challenges, and ranks things genuinely worth doing — Claude + Firecrawl, Next.js 16, PostgreSQL
TypeScript 91.5%
SQL 5.8%
CSS 2.2%
1/**2 * WorthDoing.ai3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File: src/app/opportunity/[id]/page.tsx6 * Description: Opportunity report page — streamed report, scores with confidence, evidence, competitors, skeptic case.7 */8import Link from "next/link";9import { notFound } from "next/navigation";10import { and, asc, eq, inArray } from "drizzle-orm";11import { db } from "@/lib/db/client";12import {13 opportunities,14 opportunityScores,15 opportunityEvidence,16 opportunityCompetitors,17 competitors,18 evidence,19 sources,20 investigations,21} from "@/lib/db/schema";22import { Markdown } from "@/components/wd/Markdown";23import { ScorePanel } from "@/components/wd/ScorePanel";24import { WorthScore } from "@/components/wd/OpportunityCard";25import { EvidenceKindBadge } from "@/components/wd/badges";2627export const dynamic = "force-dynamic";2829export default async function OpportunityPage({ params }: PageProps<"/opportunity/[id]">) {30 const { id } = await params;31 if (!/^[0-9a-f-]{36}$/i.test(id)) notFound();3233 const [opp] = await db.select().from(opportunities).where(eq(opportunities.id, id));34 if (!opp) notFound();3536 const [inv] = await db.select().from(investigations).where(eq(investigations.id, opp.investigationId));37 const scores = await db.select().from(opportunityScores).where(eq(opportunityScores.opportunityId, id));38 const links = await db.select().from(opportunityEvidence).where(eq(opportunityEvidence.opportunityId, id));39 const evidenceIds = links.map((l) => l.evidenceId);40 const evRows = evidenceIds.length41 ? await db42 .select({43 id: evidence.id,44 kind: evidence.kind,45 quote: evidence.quote,46 summary: evidence.summary,47 strength: evidence.strength,48 sourceUrl: sources.canonicalUrl,49 sourceTitle: sources.title,50 sourceDomain: sources.domain,51 })52 .from(evidence)53 .innerJoin(sources, eq(sources.id, evidence.sourceId))54 .where(and(eq(evidence.investigationId, opp.investigationId), inArray(evidence.id, evidenceIds)))55 .orderBy(asc(evidence.createdAt))56 : [];57 const comps = await db58 .select({59 name: competitors.name,60 url: competitors.url,61 description: competitors.description,62 note: opportunityCompetitors.note,63 })64 .from(opportunityCompetitors)65 .innerJoin(competitors, eq(competitors.id, opportunityCompetitors.competitorId))66 .where(eq(opportunityCompetitors.opportunityId, id));6768 return (69 <article className="mx-auto w-full max-w-4xl px-4 py-8 sm:px-6">70 {inv && (71 <Link72 href={`/investigate/${inv.id}`}73 className="font-mono text-[11px] uppercase tracking-wider text-tele hover:underline"74 >75 ← investigation: {inv.objective}76 </Link>77 )}7879 <header className="mt-4 flex flex-col-reverse items-start justify-between gap-4 sm:flex-row">80 <div className="min-w-0">81 <h1 className="font-heading text-3xl font-semibold leading-tight tracking-tight text-ink sm:text-4xl">82 {opp.title}83 </h1>84 <p className="mt-3 max-w-2xl text-base leading-relaxed text-ink-soft">{opp.summary}</p>85 </div>86 <div className="shrink-0 rounded-xl border border-line bg-card px-5 py-4">87 <WorthScore score={opp.worthScore} confidence={opp.evidenceConfidence} size="lg" />88 <p className="mt-1 text-right font-mono text-[9px] uppercase tracking-widest text-ink-soft">89 worth score90 </p>91 </div>92 </header>9394 <div className="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-[1fr_320px]">95 <div className="min-w-0">96 {opp.reportMd ? (97 <Markdown>{opp.reportMd}</Markdown>98 ) : (99 <p className="rounded-xl border border-line bg-card p-6 font-mono text-sm text-ink-soft">100 The report has not been synthesized yet.101 </p>102 )}103104 <section className="mt-10">105 <h2 className="mb-4 font-heading text-xl font-semibold text-ink">Evidence trail</h2>106 <ol className="space-y-3">107 {evRows.map((e, i) => (108 <li109 key={e.id}110 id={`evidence-${i + 1}`}111 className="scroll-mt-24 rounded-xl border border-line bg-card p-4"112 >113 <div className="flex items-center justify-between gap-2">114 <span className="font-mono text-[11px] font-medium text-ink">[{i + 1}]</span>115 <div className="flex items-center gap-2">116 <EvidenceKindBadge kind={e.kind} />117 <span className="font-mono text-[10px] text-ink-soft">118 strength {Math.round(e.strength * 100)}%119 </span>120 </div>121 </div>122 <blockquote className="mt-2 border-l-2 border-line pl-3 text-sm italic leading-relaxed text-ink/85">123 “{e.quote}”124 </blockquote>125 <p className="mt-2 text-[13px] text-ink-soft">{e.summary}</p>126 <a127 href={e.sourceUrl}128 target="_blank"129 rel="noopener noreferrer"130 className="mt-2 inline-block truncate font-mono text-[11px] text-tele hover:underline"131 >132 {e.sourceTitle ?? e.sourceUrl} · {e.sourceDomain}133 </a>134 </li>135 ))}136 </ol>137 </section>138 </div>139140 <aside className="space-y-5">141 <section className="rounded-xl border border-line bg-card p-4">142 <h2 className="mb-4 font-mono text-[11px] uppercase tracking-widest text-ink-soft">143 Score breakdown144 </h2>145 <ScorePanel146 scores={scores.map((s) => ({147 dimension: s.dimension,148 score: s.score,149 confidence: s.confidence,150 reasoning: s.reasoning,151 }))}152 />153 </section>154155 <section className="rounded-xl border border-signal/40 bg-signal/5 p-4">156 <h2 className="mb-2 font-mono text-[11px] uppercase tracking-widest text-signal">157 The skeptic’s case158 </h2>159 <p className="text-[13px] leading-relaxed text-ink/90">{opp.skepticCase}</p>160 </section>161162 <section className="rounded-xl border border-rust/30 bg-rust/5 p-4">163 <h2 className="mb-2 font-mono text-[11px] uppercase tracking-widest text-rust">Risks</h2>164 <p className="text-[13px] leading-relaxed text-ink/90">{opp.risks}</p>165 </section>166167 {comps.length > 0 && (168 <section className="rounded-xl border border-line bg-card p-4">169 <h2 className="mb-3 font-mono text-[11px] uppercase tracking-widest text-ink-soft">170 Competitive landscape171 </h2>172 <ul className="space-y-3">173 {comps.map((c) => (174 <li key={c.name}>175 {c.url ? (176 <a177 href={c.url}178 target="_blank"179 rel="noopener noreferrer"180 className="text-[13px] font-medium text-tele hover:underline"181 >182 {c.name}183 </a>184 ) : (185 <span className="text-[13px] font-medium text-ink">{c.name}</span>186 )}187 <p className="mt-0.5 text-xs leading-relaxed text-ink-soft">188 {c.description} {c.note && <span className="italic">— {c.note}</span>}189 </p>190 </li>191 ))}192 </ul>193 </section>194 )}195 </aside>196 </div>197 </article>198 );199}200